package com.googlecode.ducktype;

import java.lang.annotation.Annotation;
import java.lang.annotation.Inherited;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;

/**
 * Class used to match a method based, on name, annotations, number of parameters
 * and types of parameters and return value.
 */
public class MethodMatcher {
    private String methodName = null;
    private Pattern includePattern = null;
    private Pattern excludePattern = null;
    private int nrParameters = -1;
    private List<Class<?>> parameterTypes = null;
    private List<Class<? extends Annotation>> annotations = null;
    private List<Class<? extends Annotation>> excludedAnnotations = null;
    private boolean inheritInterfaceAnnotations = false;
    private Class<?> returnType = null;
    
    /**
     * Match methods whose name matches the supplied name exactly.
     *
     * @param methodName The methodName to match
     */
    public void exactMatch(String methodName) {
        this.methodName = methodName;
    }

    /**
     * Include methods whose match the supplied pattern
     *
     * @param includePattern pattern to match
     */
    public void include(Pattern includePattern) {
        this.includePattern = includePattern;
    }

    /**
     * Include methods whose match the supplied pattern
     *
     * @param excludePattern pattern to match
     */
    public void exclude(Pattern excludePattern) {
        this.excludePattern = excludePattern;
    }

    /**
     * Match the number of parameters.
     *
     * @param nrParameters number of parameters to match
     */
    public void nrParameters(int nrParameters) {
        if (hasParameterTypesCriteria()
                && parameterTypes.size() != nrParameters) {
            throw new IllegalArgumentException(
                    "Number of parameters does not match supplied parameter types list.");
        }
        this.nrParameters = nrParameters;
    }

    /**
     * Match the list of parameter types.
     *
     * @param parameterTypes List of parameter types to match
     */
    public void parameterTypes(Class<?>...parameterTypes) {
        if (hasNrParametersCriteria()
                && parameterTypes.length != nrParameters) {
            throw new IllegalStateException(
                    "Parameter types list does not match number of parameters specified.");
        }
        this.parameterTypes = Arrays.asList(parameterTypes);
    }

    /**
     * Match the return type.
     */
    public void returnType(final Class<?> returnType) {
        this.returnType = returnType;
    }
    
    /**
     * Set list of annotations to match.  The method must match all these
     * annotations, but it may have others not in this list.
     *
     * @param annotations the annotations to match
     */
    public void annotations(Class<? extends Annotation>...annotations) {
        this.annotations = Arrays.asList(annotations);
    }
    
    public void excludeAnnotations(Class<? extends Annotation>...annotations) {
        this.excludedAnnotations = Arrays.asList(annotations);
    }

    /**
     * Indicate whether annotations should be inherited by a classes implemented interfaces.
     * If set to true then when matching a method by an annotation that is marked {@link Inherited}, then
     * both the class methods and any implemented interfaces will be queried to discover the
     * annotated methods.
     *
     * <p>Note: this usage of <code>Inherited</code> is different from the standard usage (which
     * applies to Classes only), and only used to indicate inheritance from an implemented interface.
     * It may not be used to inherit annotations across a subclass.
     *
     * @param value
     */
    public void inheritInterfaceAnnotations(boolean value) {
        this.inheritInterfaceAnnotations = value;
    }

    /**
     * Find all methods in a class that match the criteria for this
     * MethodMatcher
     *
     * @param clazz class to match methods for
     * @return List of methods matched, or empty list if no matches found
     */
    public List<Method> findMethods(Class<?> clazz) {
        List<Method> result = new ArrayList<Method>();
        for (Method method : clazz.getMethods()) {
            if (matches(method)) {
                result.add(method);
            }
        }
        return result;
    }

    /**
     * Find a single method in the given class the matches the criteria for
     * this MethodMatcher.
     *
     * @param clazz  The class to find a method from
     * @return the method matched or null if no methods found
     * @throws BullseyeException if more than one method was matched
     */
    public Method findSingleMethod(Class<?> clazz) throws IllegalArgumentException {
        List<Method> methods = findMethods(clazz);
        switch(methods.size()) {
        case 0: return null;
        case 1: return methods.iterator().next();
        default: throw new IllegalArgumentException("More than one method of class \""  +
                clazz + "\" was matched by the given criteria");
        }
    }


    /**
     * Return true if a given method matches this MethodMatcher.
     *
     * @param method The method to test
     * @return true if the method matches, false otherwise
     */
    public boolean matches(Method method) {
        if (hasExactMatchCriteria() && !hasMatchingName(method)) {
            return false;
        }

        if (hasIncludeCriteria() &&
                !hasNameMatchingPattern(method, includePattern)) {
            return false;
        }

        if (hasExcludeCriteria() &&
                hasNameMatchingPattern(method, excludePattern)) {
            return false;
        }

        if (hasMatchAnnotationsCriteria() && !hasMatchingAnnotations(method)) {
            return false;
        }
        
        if (hasExcludeAnnotationsCriteria() && hasExcludedAnnotations(method)) {
            return false;
        }
        
        if (hasNrParametersCriteria() && !hasMatchingNrOfParameters(method)) {
            return false;
        }

        if (hasParameterTypesCriteria() && !hasMatchingParametersTypes(method)) {
            return false;
        }
        
        if (hasReturnTypeCriteria() && !hasMatchingReturnType(method)) {
            return false;
        }
        return true;
    }

    private boolean hasExactMatchCriteria() {
        return this.methodName != null;
    }

    private boolean hasMatchingName(Method method) {
        return methodName.equals(method.getName());
    }

    private boolean hasMatchAnnotationsCriteria() {
        return annotations != null;
    }

    private boolean hasMatchingAnnotations(Method method) {
        for (Class<? extends Annotation> annotation : annotations) {
            if (!method.isAnnotationPresent(annotation) && !hasInheritedAnnotation(method, annotation)) {
                return false;
            }
        }
        return true;
    }
    
    private boolean hasExcludeAnnotationsCriteria() {
        return excludedAnnotations != null;
    }

    private boolean hasExcludedAnnotations(Method method) {
        for (Class<? extends Annotation> annotation : excludedAnnotations) {
            if (method.isAnnotationPresent(annotation) || hasInheritedAnnotation(method, annotation)) {
                return true;
            }
        }
        return false;
    }


    private boolean hasInheritedAnnotation(Method method, Class<? extends Annotation> annotation) {
        if (method.getDeclaringClass().isInterface()) {
            return false;
        }
        if (!inheritInterfaceAnnotations) {
            return false;
        }
        if (!annotation.isAnnotationPresent(Inherited.class)) {
            return false;
        }
        Method ifaceMethod = getImplementedInterfaceMethod(method);
        if (ifaceMethod == null) {
            return false;
        }
        return ifaceMethod.isAnnotationPresent(annotation);
    }

    private Method getImplementedInterfaceMethod(Method method) {
        Method ifaceMethod;
        for (Class<?> iface : method.getDeclaringClass().getInterfaces()) {
            try {
                ifaceMethod = iface.getMethod(method.getName(), method.getParameterTypes());
            } catch (NoSuchMethodException e) {
                // Ignore
                continue;
            }
            return ifaceMethod;
        }
        return null;
    }

    private boolean hasNrParametersCriteria() {
        return nrParameters != -1;
    }

    private boolean hasMatchingNrOfParameters(Method method) {
        return method.getParameterTypes().length == nrParameters;
    }

    private boolean hasIncludeCriteria() {
        return includePattern != null;
    }

    private boolean hasExcludeCriteria() {
        return excludePattern != null;
    }

    private boolean hasNameMatchingPattern(Method method, Pattern pattern) {
        return pattern.matcher(method.getName()).matches();
    }

    private boolean hasParameterTypesCriteria() {
        return parameterTypes != null;
    }

    private boolean hasMatchingParametersTypes(Method method) {
        return Arrays.equals(parameterTypes.toArray(),
                method.getParameterTypes());
    }

    private boolean hasReturnTypeCriteria() {
        return returnType != null;
    }
    
    private boolean hasMatchingReturnType(final Method method) {
        return returnType.equals(method.getReturnType());
    }
}
